import '../util/extensions.dart';
import 'gff.dart';

final List<String> fieldNames = [
  'seq_id',
  'source',
  'type',
  'start',
  'end',
  'score',
  'strand',
  'phase',
  'attributes',
];

/// Unescape a string value used in a GFF3 attribute.
///
/// @param {String} s
/// @returns {String}
unescape(String s) {
  if (s == null) return null;
  return s.replaceAllMapped(RegExp('%([0-9A-Fa-f]{2})'), (match) {
    return String.fromCharCode(int.parse(match.group(1), radix: 16));
  });
}

/// Escape a value for use in a GFF3 attribute value.
///
/// @param {String} s
/// @returns {String}
_escape(regex, String s) {
  return s.replaceAllMapped(RegExp(regex), (match) {
    var hex = match.group(0).codeUnitAt(0).toRadixString(16).toUpperCase();

    // lol, apparently there's no native function for fixed-width hex output
    if (hex.length < 2) hex = '0$hex';
    return '%$hex';
  });
}

String escape(String s) {
  return _escape('[\n;\r\t=%&,\x00-\x1f\x7f-\xff]', s);
}

/// Escape a value for use in a GFF3 column value.
///
/// @param {String} s
/// @returns {String}
escapeColumn(String s) {
  return _escape('[\n\r\t%\x00-\x1f\x7f-\xff]', s);
}

/// Parse the 9th column (attributes) of a GFF3 feature line.
///
/// @param {String} attrString
/// @returns {Object}
Map parseAttributes(String attrString) {
  if ((attrString == null || attrString.length == 0) || attrString == '.') return {};
  Map attrs = {};

  attrString.replaceAll(RegExp('\r?\n\$'), '').split(';').forEach((String a) {
    var nv = a.splitWithCount('=', 2); //2
    if (nv.length < 2 || (nv[1] == null || nv[1].length == 0)) return;

    nv[0] = nv[0].trim();
    List arec = attrs[nv[0].trim()];
    if (arec == null) {
      arec = [];
      attrs[nv[0]] = arec;
    }

    arec.addAll(
      nv[1].split(',').map((s) => s.trim()).map(unescape),
    );
  });
  return attrs;
}

/// Parse a GFF3 feature line
///
/// @param {String} line
GffFeature parseFeature(String line) {
  // split the line into columns and replace '.' with null in each column
  List<dynamic> f = line.split('\t').map((a) => (a == '.' ? null : a)).toList();

  // unescape only the ref, source, and type columns
  f[0] = unescape(f[0]);
  f[1] = unescape(f[1]);
  f[2] = unescape(f[2]);

//  f[8] = parseAttributes(f[8]);
  Map parsedMap = {};

  for (var i = 0; i < fieldNames.length; i += 1) {
    parsedMap[fieldNames[i]] = i >= f.length || f[i] == '.' ? null : f[i];
  }
  parsedMap[fieldNames[8]] = f.length >= 9 ? parseAttributes(f[8]) : {};

  GffFeature parsed = GffFeature(parsedMap);
  if (parsed.start != null) parsed.start = int.tryParse(parsedMap['start']);
  if (parsed.end != null) parsed.end = int.tryParse(parsedMap['end']);
  if (parsed.score != null) parsed.score = double.tryParse(parsedMap['score']);
  if (parsed.strand != null) parsed.strand = parsed.strand;
  return parsed;
}

/// Parse a GFF3 directive line.
///
/// @param {String} line
/// @returns {Object} the information in the directive
DirectiveFeature parseDirective(String line) {
  var match = RegExp(r'^\s*##\s*(\S+)\s*(.*)').firstMatch(line);
  if (match == null) return null;

//    let [, name, contents] = match;
  String name = match.group(1);
  String contents = match.group(2);

  Map parsedMap = {'directive': name};
  if (contents.length > 0) {
    contents = contents.replaceAll(RegExp('\r?\n\$'), '');
    parsedMap['value'] = contents;
  }

  DirectiveFeature parsed = DirectiveFeature(parsedMap);

  // do a little additional parsing for sequence-region and genome-build directives
  if (name == 'sequence-region') {
    List<String> c = contents.splitWithCount(RegExp(r'\s+'), 3); //3
    parsed.seq_id = c[0];
    parsed.start = int.tryParse(c[1].replaceAll(RegExp(r'\D'), ''));
    parsed.end = int.tryParse(c[2].replaceAll(RegExp(r'\D'), ''));
  } else if (name == 'genome-build') {
    List arr = contents.splitWithCount(RegExp(r'\s+'), 2); //2
    parsed.source = arr[0];
    parsed.buildName = arr[1];
  }

  return parsed;
}

/// Format an attributes object into a string suitable for the 9th column of GFF3.
///
/// @param {Object} attrs
formatAttributes(Map attrs) {
  List attrOrder = [];
  attrs.keys.forEach((tag) {
    var val = attrs[tag];
    var valstring;
    if (val is Map) {
      valstring = val.values.map((e) => escape(e)).join(',');
    } else if (val is List) {
      valstring = val.map((e) => escape(e)).join(',');
    } else {
      valstring = escape('$val');
    }
    attrOrder.add('${escape(tag)}=$valstring');
  });
  return attrOrder.length > 0 ? attrOrder.join(';') : '.';
}

final List translateStrand = ['-', '.', '+'];

_formatSingleFeature(GffFeature f, seenFeature) {
  var attrString = f.attributes == null ? '.' : formatAttributes(f.attributes);

  List fields = List.generate(9, (i) {
    var val = f[fieldNames[i]];
    // deserialize strand
    if (i == 6)
      return val == null ? '.' : translateStrand.firstWhere((e) => e == val, orElse: () => null) ?? val;
    else
      return val == null ? '.' : escapeColumn('$val');
  });
//  for (var i = 0; i < 8; i += 1) {
//    var val = f[fieldNames[i]];
//    // deserialize strand
//    if (i == 6)
//      fields[i] = val == null ? '.' : translateStrand[val + 1] || val;
//    else
//      fields[i] = val == null ? '.' : escapeColumn(val);
//  }
  fields[8] = attrString;

  String formattedString = '${fields.join('\t')}\n';

  // if we have already output this exact feature, skip it
  if (seenFeature[formattedString] ?? false) {
    return '';
  }
  seenFeature[formattedString] = true;
  return formattedString;
}

_formatFeature(feature, seenFeature) {
  if (feature is List) {
    return feature.map((f) => _formatFeature(f, seenFeature)).join('');
  }

  List strings = [_formatSingleFeature(feature, seenFeature)];
  ['child_features', 'derived_features'].forEach((multiSlot) => {
        if (feature[multiSlot] != null)
          {
            strings.addAll(
              feature[multiSlot].map((f) => _formatFeature(f, seenFeature)),
            )
          }
      });
  return strings.join('');
}

/// Format a feature object or array of
/// feature objects into one or more lines of GFF3.
///
/// @param {Object|Array[Object]} featureOrFeatures
formatFeature(featureOrFeatures) {
  final Map seen = {};
  return _formatFeature(featureOrFeatures, seen);
}

/// Format a directive into a line of GFF3.
///
/// @param {Object} directive
/// @returns {String}
formatDirective(directive) {
  var str = '##${directive.directive}';
  if (directive.value) str += ' ${directive.value}';
  str += '\n';
  return str;
}

/// Format a comment into a GFF3 comment.
/// Yes I know this is just adding a # and a newline.
///
/// @param {Object} comment
/// @returns {String}
formatComment(comment) {
  return '# ${comment.comment}\n';
}

/// Format a sequence object as FASTA
///
/// @param {Object} seq
/// @returns {String} formatted single FASTA sequence
formatSequence(seq) {
  return '>${seq.id}${seq.description ? ' ${seq.description}' : ''}\n${seq.sequence}\n';
}

/// Format a directive, comment, or feature,
/// or array of such items, into one or more lines of GFF3.
///
/// @param {Object|Array} itemOrItems
formatItems(List items) {
  return items.map((e) => formatSingleItem).toList();
}

formatSingleItem(item) {
  if (item[0] || item.attributes) return formatFeature(item);
  if (item.directive != null) return formatDirective(item);
  if (item.sequence != null) return formatSequence(item);
  if (item.comment != null) return formatComment(item);
  return '# (invalid item found during format)\n';
}
